Перейти к основному содержимому

5.03. Работа с БД

Разработчику Архитектору

Работа с БД

Работа с постоянным хранилищем данных — одна из центральных задач практически любого нетривиального приложения на языке Java. Независимо от того, разрабатывается ли веб-сервис, настольное приложение или микросервис в распределённой инфраструктуре, рано или поздно возникает необходимость чтения, модификации и сохранения структурированной информации. В экосистеме Java для этой цели сформировалась многоуровневая архитектура доступа к данным, отвечающая разным требованиям к производительности, выразительности кода, уровню абстракции и удобству обслуживания.

Наиболее фундаментальный слой этой архитектуры — JDBC (Java Database Connectivity) — представляет собой стандартный API, входящий в состав Java SE и обеспеченный спецификацией от Oracle. Его задача — предоставить унифицированный программный интерфейс для взаимодействия с реляционными базами данных, независимо от конкретной системы управления базами данных (СУБД). JDBC не содержит реализации для конкретных СУБД; вместо этого он определяет контракт, который реализуется через так называемые JDBC-драйверы — библиотеки, поставляемые разработчиками СУБД (PostgreSQL, MySQL, Oracle и др.) или независимыми сообществами.

Важно понимать, что JDBC — это интерфейсный слой между приложением и СУБД. Он не скрывает SQL, не преобразует объекты автоматически и не управляет транзакциями на высоком уровне. Вместо этого он делает возможным выполнение SQL-инструкций, управление соединениями, обработку результатов и диагностику ошибок в строго типизированной манере, характерной для языка Java. Именно эта «близна к железу» делает JDBC незаменимым в сценариях, где требуется полный контроль над выполняемыми запросами, оптимизация на уровне СУБД или интеграция с системами, не поддерживающими более высокоуровневые абстракции.

Особенности работы с БД в Java

Работа с базами данных в Java обладает рядом специфических черт, которые формируют как возможности, так и сложности разработки:

Строгая типизация и безопасность. Java — статически типизированный язык, и JDBC, как часть стандартной библиотеки, в полной мере использует эту особенность. Например, при получении значения из ResultSet разработчик обязан явно указать ожидаемый тип (getString, getInt, getTimestamp и т.п.), что предотвращает неявные преобразования и упрощает выявление ошибок на этапе компиляции или ранней стадии выполнения. Однако это требует большей детализации по сравнению с динамическими языками.

Отсутствие встроенного ORM. В отличие от некоторых других платформ (например, .NET с Entity Framework Core или Python с Django ORM), стандартная поставка Java не включает объектно-реляционное отображение. ORM-решения в Java — это продукт экосистемы: Hibernate, EclipseLink, OpenJPA и др. Это означает, что выбор уровня абстракции остаётся за разработчиком, но также требует дополнительного изучения и интеграционных усилий.

Сильная зависимость от внешних компонентов. Для работы с конкретной СУБД необходимо наличие соответствующего JDBC-драйвера, который не является частью JDK. Это означает, что приложение не может быть полностью автономным — оно зависит от внешней библиотеки, совместимой с версией СУБД и архитектурой развертывания.

Управление ресурсами вручную (на низком уровне). JDBC требует явного открытия и закрытия соединений, операторов и наборов результатов. Несоблюдение этого правила приводит к утечкам ресурсов, исчерпанию пулов соединений и падению производительности. Хотя современные практики (try-with-resources, пулы соединений, Spring-managed resources) значительно снижают риски, на уровне API ответственность лежит на программисте.

Декларативность SQL vs. императивность Java. SQL — язык декларативный: вы описываете что вы хотите получить, а не как. Java же — императивный язык: вы описываете последовательность действий. JDBC служит мостом между этими парадигмами: он позволяет интегрировать SQL в Java-код, но не устраняет семантическую дистанцию между ними. Именно эта дистанция и становится основной мотивацией для появления ORM.

Основные подходы к работе с БД в Java

Можно выделить четыре основных подхода к взаимодействию с базами данных в Java, формирующих иерархию от низкоуровневого к высокоуровневому:

  1. JDBC (Java Database Connectivity) — стандартный, низкоуровневый API. Обеспечивает прямое выполнение SQL, полный контроль над соединениями и запросами, но требует значительного объема шаблонного кода: установка соединения, подготовка инструкции, обработка ResultSet, безопасное освобождение ресурсов, обработка исключений. Подходит для небольших приложений, утилит, инструментальных библиотек, а также для случаев, когда необходима тонкая настройка запросов (например, использование нативных расширений СУБД).

  2. SQL-фреймворки (обёртки над JDBC) — такие как jOOQ, MyBatis (ранее iBatis), QueryDSL. Они не скрывают SQL, но устраняют большую часть шаблонного кода JDBC, добавляя типобезопасность, fluent-интерфейсы, генерацию кода на основе схемы БД и автоматическое управление ресурсами. Например, jOOQ позволяет строить запросы в стиле DSL, близком к SQL, но с проверкой типов на этапе компиляции. MyBatis позволяет оставлять SQL в XML или аннотациях, но связывает результаты с Java-объектами без необходимости писать rs.getString("name") вручную.

  3. ORM (Object-Relational Mapping) — такие как Hibernate, EclipseLink, OpenJPA. Их цель — устранить импедансное несоответствие между объектной моделью Java и реляционной моделью БД. Разработчик работает с обычными Java-классами (сущностями), а ORM автоматически генерирует и выполняет необходимые SQL-запросы, управляет транзакциями, кэшированием, ленивой загрузкой связанных объектов и другими нетривиальными аспектами. Это повышает продуктивность, но вводит дополнительную сложность: нужно понимать поведение ORM, избегать антипаттернов (например, N+1), настраивать стратегии извлечения и синхронизации.

  4. Экосистемные фреймворки (Spring Data, Micronaut Data и др.) — надстройки над ORM и SQL-фреймворками, предоставляющие дополнительные абстракции: репозитории, проекции, автоматическую генерацию query-методов по имени, интеграцию с транзакционным менеджером, поддержку реактивного программирования. Spring Data JPA, например, позволяет объявить интерфейс UserRepository extends JpaRepository<User, Long>, и получить полный набор CRUD-операций без реализации — Spring сгенерирует реализацию динамически во время выполнения или компиляции. Это достигает максимальной декларативности в enterprise-разработке.

Ниже мы подробно рассмотрим первый и наиболее фундаментальный уровень — JDBC.


JDBC

JDBC был введён в Java 1.1 (1997 г.) и с тех пор остаётся неизменным по своей сути: это набор интерфейсов и классов в пакете java.sqljavax.sql — для расширенных возможностей, таких как пулы соединений и распределённые транзакции). Несмотря на появление более современных подходов, JDBC остаётся основой всех остальных решений — даже Hibernate в конечном итоге вызывает JDBC API, пусть и через несколько слоёв абстракции.

Архитектурная схема JDBC

JDBC следует классической схеме «приложение — драйвер — СУБД». Приложение взаимодействует только с интерфейсами JDBC. При загрузке класса драйвера (например, com.mysql.cj.jdbc.Driver) он регистрируется в DriverManager. Когда приложение запрашивает соединение через DriverManager.getConnection(...), менеджер выбирает подходящий драйвер на основе URL-префикса (например, jdbc:mysql://...), и передаёт управление ему. Драйвер, в свою очередь, устанавливает сетевое соединение с сервером СУБД, аутентифицирует клиента и возвращает реализацию интерфейса Connection, специфичную для этой СУБД.

Таким образом, приложение не зависит от конкретной базы данных на уровне кода — зависимость реализуется через конфигурацию (URL, драйвер в classpath). Это позволяет, например, переключиться с H2 на PostgreSQL, изменив лишь строку подключения и зависимость в сборке.

Ключевые компоненты JDBC

Рассмотрим основные интерфейсы и классы, участвующие в жизненном цикле запроса.

1. DriverManager

Это служебный класс, управляющий загруженными драйверами. В ранних версиях Java требовалось явно вызывать Class.forName("com.mysql.cj.jdbc.Driver"), чтобы загрузить и зарегистрировровать драйвер. Современные драйверы (с JDBC 4.0+, Java 6+) используют механизм Service Provider Interface (SPI): при наличии драйвера в classpath он автоматически регистрируется при первом обращении к DriverManager. Поэтому явный вызов Class.forName(...) сегодня не обязателен — достаточно добавить артефакт в зависимости сборки.

2. Connection

Представляет активное соединение с базой данных. Через него создаются объекты Statement, управляется режим транзакций (setAutoCommit, commit, rollback), извлекаются метаданные базы (getMetaData) и проверяется состояние (isClosed). Соединение — это тяжеловесный ресурс: установка требует сетевого handshake, аутентификации и инициализации сессии на стороне СУБД. Поэтому в реальных приложениях напрямую DriverManager.getConnection() почти не используется. Вместо этого применяются пулы соединений (connection pools): библиотеки вроде HikariCP, Apache Commons DBCP, Tomcat JDBC Pool, которые удерживают пул готовых соединений и выдают их по запросу, автоматически восстанавливая разорванные связи и контролируя время жизни. Использование пула повышает производительность в многопоточной среде и защищает от исчерпания лимитов СУБД.

3. Statement, PreparedStatement, CallableStatement

Эти интерфейсы отвечают за выполнение SQL-инструкций.

  • Statement — базовый интерфейс для выполнения статических SQL-запросов без параметров. Его методы: executeQuery() (для SELECT), executeUpdate() (для INSERT, UPDATE, DELETE, DDL), execute() (универсальный, редко используется). Уязвим к SQL-инъекциям при конкатенации строк:

    String sql = "SELECT * FROM users WHERE name = '" + userInput + "'";
    // опасно: userInput = "'; DROP TABLE users; --"
  • PreparedStatement — параметризованный вариант Statement. Запрос формулируется с заполнителями (?), а значения подставляются отдельно, через методы setString, setInt и т.д. Это делает запрос типобезопасным и защищённым от инъекций, поскольку значения передаются в бинарном виде, а не как часть SQL-текста. Кроме того, СУБД может кэшировать план выполнения для таких запросов, что ускоряет повторные вызовы. PreparedStatement — де-факто стандарт для любых запросов с переменными.

  • CallableStatement — расширение для вызова хранимых процедур (CALL procedure_name(...)). Позволяет работать с входными (IN), выходными (OUT) и входо-выходными (INOUT) параметрами.

4. ResultSet

Объект, содержащий результат выполнения SELECT-запроса. Представляет собой курсор над строками результата. По умолчанию — прокручиваемый только вперёд (TYPE_FORWARD_ONLY), однопоточный (CONCUR_READ_ONLY). Поддерживаются более сложные режимы (TYPE_SCROLL_INSENSITIVE, TYPE_SCROLL_SENSITIVE, CONCUR_UPDATABLE), но их поддержка зависит от драйвера и СУБД и редко используется в практике.

Каждая строка ResultSet — это упорядоченный набор столбцов, к которым можно обращаться по индексу (начиная с 1) или по имени. Важно: методы getXXX() не создают новых объектов при повторном вызове — они считывают данные из текущей строки буфера драйвера. Перед чтением следующей строки необходимо вызвать next(); при инициализации курсор находится перед первой строкой.

Обработка ResultSet — наиболее трудоёмкая часть JDBC-кода, требующая аккуратного преобразования типов и отображения на доменные объекты.

5. SQLException

Базовый класс исключений JDBC. Он наследуется от java.lang.Exception, но начиная с Java 7 поддерживает цепочки исключений (chained exceptions) и SQL-состояния (SQLState — пятизначный код по стандарту SQL:2003, например, 23505 — нарушение уникального ограничения). Также содержит код ошибки, специфичный для СУБД (getErrorCode()). Корректная обработка SQLException требует анализа SQLState, а не только сообщения, поскольку сообщения могут быть локализованы.


Универсальный алгоритм работы с БД через JDBC

Хотя конкретные реализации различаются, любой сценарий доступа к данным через JDBC можно описать как последовательность из семи этапов. Некоторые из них (например, создание БД) выполняются вне приложения — на этапе подготовки инфраструктуры. Остальные — в коде.

Этап 1. Проектирование схемы данных

Перед написанием кода необходимо определить структуру хранилища: выбрать СУБД (исходя из требований к ACID, масштабируемости, лицензированию), спроектировать таблицы, поля, индексы, внешние ключи, ограничения целостности. В Java это не отражается напрямую, но влияет на формулировку запросов и маппинг объектов. Например, составные первичные ключи потребуют явного указания в аннотациях JPA (@IdClass или @EmbeddedId) или ручной обработки в JDBC.

Этап 2. Подключение JDBC-драйвера

Драйвер добавляется как зависимость сборки. Примеры для Maven:

<!-- PostgreSQL -->
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>42.7.3</version>
</dependency>

<!-- MySQL -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<version>8.3.0</version>
</dependency>

<!-- SQLite -->
<dependency>
<groupId>org.xerial</groupId>
<artifactId>sqlite-jdbc</artifactId>
<version>3.44.1.0</version>
</dependency>

Для Oracle драйвер (ojdbc11) не публикуется в Maven Central по лицензионным причинам — его нужно загрузить вручную с сайта Oracle и установить в локальный репозиторий или enterprise-репозиторий (Nexus, Artifactory).

Этап 3. Настройка параметров подключения

Строка подключения (JDBC URL) имеет общий формат:

jdbc:<subprotocol>://<host>:<port>/<database>?<parameters>

Примеры:

  • jdbc:postgresql://localhost:5432/mydb?currentSchema=public&sslmode=disable
  • jdbc:mysql://192.168.1.10:3306/appdb?useSSL=false&serverTimezone=UTC
  • jdbc:sqlite:/path/to/database.db (файл на диске)

Параметры аутентификации (логин/пароль) обычно передаются отдельно через DriverManager.getConnection(url, user, password). Однако в промышленной разработке они никогда не хранятся в коде — только в конфигурационных файлах (application.properties, application.yml), переменных окружения или secure vault (HashiCorp Vault, AWS Secrets Manager). В Spring Boot это выглядит так:

spring.datasource.url=jdbc:postgresql://db.example.com:5432/app
spring.datasource.username=${DB_USER}
spring.datasource.password=${DB_PASSWORD}
spring.datasource.driver-class-name=org.postgresql.Driver

Этап 4. Установление соединения

Простейший способ (не для production):

String url = "jdbc:postgresql://localhost:5432/test";
String user = "admin";
String password = "secret";

try (Connection conn = DriverManager.getConnection(url, user, password)) {
// работа с БД
} catch (SQLException e) {
// обработка ошибки
}

Использование try-with-resources гарантирует, что соединение будет закрыто даже при возникновении исключения. В enterprise-приложениях вместо DriverManager используется DataSource — интерфейс из javax.sql, инкапсулирующий логику получения соединений. Пулы соединений (HikariCP и др.) реализуют DataSource, и Spring управляет им автоматически:

@Configuration
public class DataSourceConfig {
@Bean
public DataSource dataSource() {
HikariConfig config = new HikariConfig();
config.setJdbcUrl("jdbc:postgresql://localhost:5432/test");
config.setUsername("admin");
config.setPassword("secret");
config.setMaximumPoolSize(20);
return new HikariDataSource(config);
}
}

Этап 5. Формирование и выполнение запросов

Пример выполнения параметризованного запроса с PreparedStatement:

String sql = "SELECT id, name, email FROM users WHERE created_after > ? AND status = ?";
try (PreparedStatement stmt = conn.prepareStatement(sql)) {
stmt.setTimestamp(1, Timestamp.from(Instant.now().minus(Duration.ofDays(30))));
stmt.setString(2, "ACTIVE");

try (ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
long id = rs.getLong("id");
String name = rs.getString("name");
String email = rs.getString("email");
// преобразование в доменный объект
}
}
}

Обратите внимание на вложенность try-with-resources: PreparedStatement и ResultSet тоже требуют освобождения, и try-with-resources гарантирует их закрытие в правильном порядке (обратном порядку открытия).

Для модификации данных:

String sql = "INSERT INTO orders (user_id, amount, created_at) VALUES (?, ?, ?)";
try (PreparedStatement stmt = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {
stmt.setLong(1, userId);
stmt.setBigDecimal(2, amount);
stmt.setTimestamp(3, Timestamp.from(Instant.now()));

int rowsAffected = stmt.executeUpdate(); // возвращает количество изменённых строк

// если нужно получить сгенерированный первичный ключ (например, SERIAL в PostgreSQL)
try (ResultSet keys = stmt.getGeneratedKeys()) {
if (keys.next()) {
long orderId = keys.getLong(1);
// использовать orderId
}
}
}

Флаг Statement.RETURN_GENERATED_KEYS обязателен для доступа к getGeneratedKeys(). Не все СУБД поддерживают возврат нескольких сгенерированных ключей (например, при INSERT ... RETURNING * в PostgreSQL это возможно, но интерфейс JDBC ограничен).

Этап 6. Обработка результатов и исключений

Преобразование ResultSet в объект — повторяющаяся задача. Вручную это выглядит так:

public User mapRow(ResultSet rs) throws SQLException {
User user = new User();
user.setId(rs.getLong("id"));
user.setName(rs.getString("name"));
user.setEmail(rs.getString("email"));
user.setCreatedAt(rs.getTimestamp("created_at").toLocalDateTime());
return user;
}

Если столбец может быть NULL, следует использовать проверку rs.wasNull() после getXXX(), или предпочесть getObject() с кастомной логикой. Например, rs.getTimestamp("deleted_at") вернёт null, если поле NULL, но .toLocalDateTime() вызовет NPE — нужно проверять результат перед преобразованием.

Обработка исключений должна учитывать типы ошибок:

  • SQLTimeoutException — таймаут запроса;
  • SQLTransientException — временная ошибка (например, разрыв сети), возможно повторить;
  • SQLNonTransientException — фатальная ошибка (нарушение ограничения, синтаксическая ошибка), повтор бессмысленен.

Этап 7. Освобождение ресурсов

Как уже отмечалось, try-with-resources — стандартный и рекомендуемый способ. Порядок закрытия:

  1. ResultSet
  2. Statement / PreparedStatement
  3. Connection

Если ресурсы создаются вне try-блока, закрытие должно происходить в finally с проверкой на null и подавлением исключений при закрытии (так как исключение закрытия менее важно, чем исключение выполнения).


ORM, JPA и экосистемные абстракции

Если JDBC — это инструмент, позволяющий выполнять SQL в среде Java, то объектно-реляционное отображение (ORM) — это парадигма, позволяющая думать о данных в терминах объектов и отношений между ними, а не таблиц и строк. ORM решает так называемую задачу импедансного несоответствия (impedance mismatch) — концептуальную дистанцию между объектно-ориентированной моделью приложения и реляционной моделью хранения.

Эта дистанция проявляется в нескольких аспектах:

Гранулярность: в объектной модели допустимы сложные вложенные структуры (объект содержит список других объектов, каждый из которых может содержать ещё что-то), тогда как в реляционной модели данные нормализованы по таблицам, и связи реализуются через внешние ключи.

Наследование: Java поддерживает иерархии наследования, но реляционные СУБД — нет. Как отобразить иерархию классов Payment → CreditCardPayment | BankTransferPayment на таблицы? Есть несколько стратегий (одна таблица на иерархию, одна таблица на подкласс, объединяющая таблица), и ORM должен поддерживать их все.

Целостность идентичности: в Java два объекта считаются одинаковыми, если a == b (один и тот же экземпляр) или a.equals(b) (логическое равенство). В БД — если значения первичного ключа совпадают. ORM должен отслеживать, какой объект в памяти соответствует какой строке в БД, и не создавать дубликатов.

Жизненный цикл: объект в Java создаётся через new, уничтожается сборщиком мусора. Строка в БД создаётся через INSERT, удаляется через DELETE, изменяется через UPDATE. ORM вводит понятие состояния сущности: transient (не связан с БД), managed/persistent (отслеживается менеджером, изменения будут сохранены), detached (был связан, но сессия закрыта), removed (отмечен на удаление).

Ленивые вычисления: в Java поле объекта либо загружено, либо null. В ORM можно объявить связь как ленивую (LAZY), и тогда содержимое будет загружено только при первом обращении к геттеру — даже если объект был получен из БД час назад. Это требует проксирования и управления контекстом.

Именно для стандартизации этих концепций и появился JPA (Java Persistence API).


JPA

JPA — это спецификация, разрабатываемая в рамках Java EE (ныне Jakarta EE), определяющая единый API для работы с постоянным хранилищем в enterprise-приложениях. Важно подчеркнуть: JPA — это не библиотека и не фреймворк. Это набор интерфейсов (EntityManager, Entity, Query, PersistenceContext и др.) и правил их взаимодействия. Реализации JPA предоставляют конкретные провайдеры: Hibernate (наиболее распространённый), EclipseLink (референсная реализация от Eclipse Foundation), OpenJPA (Apache), DataNucleus.

Преимущества стандартизации очевидны: приложение, написанное на JPA, может быть перенесено с Hibernate на EclipseLink простой заменой зависимости и, возможно, нескольких конфигурационных параметров — без изменения основного кода. Это особенно ценно в крупных проектах, где политика выбора технологий может меняться со временем.

JPA строится на трёх китах:

1. Аннотации для описания сущностей

Вместо ручного маппинга ResultSet → объект, разработчик помечает Java-класс аннотацией @Entity и описывает, как его поля соотносятся со столбцами таблицы:

@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column(name = "full_name", nullable = false, length = 100)
private String name;

@Column(unique = true)
private String email;

@Enumerated(EnumType.STRING)
private Status status;

@Temporal(TemporalType.TIMESTAMP)
@Column(name = "created_at")
private Date createdAt;

// геттеры и сеттеры (или final поля + конструктор — но требует конфигурации)
}

Каждая аннотация — декларативная инструкция для провайдера:

  • @Entity — класс является сущностью;
  • @Table — уточняет имя таблицы (по умолчанию — имя класса);
  • @Id — первичный ключ;
  • @GeneratedValue — стратегия генерации (автоинкремент, sequence, UUID и др.);
  • @Column — параметры столбца: имя, nullable, unique, length, precision;
  • @Enumerated — как хранить enum: как строку или порядковый номер;
  • @Temporal — для совместимости с Java 7/8 (устарело в JPA 2.2+, где LocalDateTime поддерживается напрямую).

Обратите внимание: нет SQL в коде. Нет упоминания СУБД. Схема базы данных может быть создана автоматически провайдером (для разработки и тестов) или сверена с существующей (validation). Это достигается через свойство javax.persistence.schema-generation.database.action (create, drop-and-create, none, validate).

2. EntityManager — центр управления состоянием

EntityManager — это интерфейс, через который выполняются все операции с сущностями: создание, чтение, обновление, удаление, выполнение запросов. Он инкапсулирует так называемый контекст постоянства (persistence context) — кэш первого уровня, в котором хранятся все управляемые (managed) сущности в рамках одной транзакции.

Пример CRUD-операций:

// получение EntityManager (в Spring Boot — инъекция через @PersistenceContext)
@Transactional
public void transferFunds(Long fromId, Long toId, BigDecimal amount) {
User from = em.find(User.class, fromId); // загружает сущность по ID
User to = em.find(User.class, toId);

if (from.getBalance().compareTo(amount) < 0) {
throw new InsufficientFundsException();
}

from.setBalance(from.getBalance().subtract(amount));
to.setBalance(to.getBalance().add(amount));

// никаких em.update() не требуется!
// изменения автоматически синхронизируются с БД при коммите транзакции (flush)
}

Здесь ключевой момент: изменения в управляемых сущностях отслеживаются автоматически. Это так называемый dirty checking — ORM сравнивает состояние объекта на момент загрузки и перед коммитом, и генерирует UPDATE, только если что-то изменилось. Никакого ручного вызова save() или update() не нужно — за исключением вставки новых (em.persist(entity)).

Также EntityManager предоставляет методы:

  • persist(entity) — переводит transient-объект в managed;
  • remove(entity) — помечает сущность на удаление;
  • merge(entity) — принимает detached-объект, копирует его состояние в managed-экземпляр и возвращает его;
  • detach(entity) — отвязывает объект от контекста;
  • clear() — очищает весь контекст (редко используется).

3. Язык запросов: JPQL и Criteria API

JPA предлагает два способа формулировать запросы, не используя нативный SQL:

JPQL (Java Persistence Query Language) — объектно-ориентированный аналог SQL. Вместо имён таблиц и столбцов используются имена классов и полей:

// Выборка всех активных пользователей с балансом > 1000
String jpql = """
SELECT u FROM User u
WHERE u.status = :status AND u.balance > :minBalance
ORDER BY u.createdAt DESC
""";
TypedQuery<User> query = em.createQuery(jpql, User.class);
query.setParameter("status", Status.ACTIVE);
query.setParameter("minBalance", BigDecimal.valueOf(1000));
List<User> users = query.getResultList();

JPQL поддерживает JOIN (включая FETCH JOIN для упреждающей загрузки связей), агрегаты (COUNT, SUM), подзапросы, функции. Он проверяется при компиляции (если использовать TypedQuery), но не является типобезопасным на 100 %: имя поля balance — строка, и ошибка опечатки обнаружится только в runtime.

Criteria API — программный способ построения запросов. Позволяет конструировать JPQL-подобные запросы в виде Java-кода, с полной типобезопасностью и поддержкой IDE (автодополнение, рефакторинг):

CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<User> cq = cb.createQuery(User.class);
Root<User> user = cq.from(User.class);

cq.select(user)
.where(
cb.equal(user.get("status"), Status.ACTIVE),
cb.gt(user.get("balance"), BigDecimal.valueOf(1000))
)
.orderBy(cb.desc(user.get("createdAt")));

TypedQuery<User> query = em.createQuery(cq);
List<User> users = query.getResultList();

Основные преимущества Criteria API:

  • безопасность при рефакторинге (изменение имени поля в классе вызовет ошибку компиляции);
  • динамическое построение запросов (например, фильтрация по произвольному набору условий);
  • совместимость с метамоделью (автогенерируемые классы User_, где User_.balance — типизированный путь).

Недостаток — громоздкость синтаксиса. Поэтому на практике часто комбинируют: JPQL для статических запросов, Criteria API — для динамических.


Hibernate

Hibernate — полноценный ORM-фреймворк, существовавший до появления JPA и оказавший значительное влияние на формирование стандарта. В режиме JPA он следует спецификации, но предоставляет также нативные API и функции, недоступные через стандартный интерфейс. Это делает его гибким, но привязывает код к Hibernate, если эти функции используются.

Ключевые возможности Hibernate, выходящие за рамки JPA:

Hibernate Query Language (HQL) — расширение JPQL. Поддерживает:

  • нативные функции СУБД (function('lower', u.name));
  • паттерны коллекций (elements(u.roles));
  • обновления и удаления через HQL (UPDATE User u SET u.status = 'INACTIVE' WHERE ...);
  • оконные функции (в новых версиях);
  • подзапросы в SELECT.

Кэширование второго уровня. JPA определяет только кэш первого уровня (в рамках EntityManager). Hibernate добавляет кэш второго уровня — общий для всех сессий в приложении. Он может использовать Ehcache, Infinispan, Caffeine и др. Полезен для часто читаемых, редко изменяемых данных (справочники, настройки). Управление — через аннотации (@Cacheable, @Cache) и конфигурацию.

Ленивая загрузка и прокси. При запросе сущности с ленивыми связями (@OneToMany(fetch = FetchType.LAZY)) Hibernate создаёт прокси-объект — динамический подкласс, переопределяющий геттеры так, что при первом обращении к коллекции выполняется дополнительный запрос. Это требует, чтобы сессия (Session, аналог EntityManager) была открыта в момент доступа — иначе возникнет LazyInitializationException. В веб-приложениях это решается через Open Session in View (антипаттерн, не рекомендуется) или DTO-проекции с JOIN FETCH.

Batch-обработка. Для массовой вставки/обновления Hibernate поддерживает пакетную обработку: при включённой настройке hibernate.jdbc.batch_size несколько INSERT объединяются в один сетевой запрос. Требует, чтобы первичные ключи генерировались не через IDENTITY (MySQL, MS SQL), а через SEQUENCE или TABLE, поскольку IDENTITY требует немедленного извлечения сгенерированного ID.

Кастомные типы и user types. Возможность определить, как Java-тип (например, Money, PhoneNumber) отображается на один или несколько столбцов. Используется @Type, @ColumnTransformer, @Embeddable.

Настройка Hibernate

В Spring Boot основные параметры задаются в application.properties:

# Включить генерацию схемы (только для dev!)
spring.jpa.hibernate.ddl-auto=validate

# Включить логгирование SQL (не в промышленной эксплуатации)
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true

# Пул соединений (HikariCP управляется отдельно)
spring.datasource.hikari.maximum-pool-size=20

# Hibernate-specific
spring.jpa.properties.hibernate.jdbc.batch_size=20
spring.jpa.properties.hibernate.order_inserts=true
spring.jpa.properties.hibernate.order_updates=true
spring.jpa.properties.hibernate.cache.use_second_level_cache=true
spring.jpa.properties.hibernate.cache.region.factory_class=jcache

Обратите внимание: spring.jpa.hibernate.ddl-auto=create-drop опасен в production — он уничтожит данные при старте приложения. Рекомендуется использовать инструменты миграций (Flyway, Liquibase) для управления схемой.


Spring Data JPA

Если JPA устраняет boilerplate-код для CRUD, то Spring Data JPA устраняет даже интерфейсы для этого кода. Его идея проста: вместо реализации репозитория вручную, вы объявляете интерфейс, наследуя от JpaRepository<T, ID>, и Spring динамически создаёт реализацию во время запуска приложения.

Базовый репозиторий

public interface UserRepository extends JpaRepository<User, Long> {
// уже доступны:
// Optional<User> findById(Long id);
// List<User> findAll();
// User save(User user);
// void deleteById(Long id);
// и десятки других методов
}

Инъекция в сервис:

@Service
@Transactional
public class UserService {
private final UserRepository userRepository;

public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}

public User register(String email, String name) {
User user = new User();
user.setEmail(email);
user.setName(name);
user.setStatus(Status.PENDING);
return userRepository.save(user); // вставляет или обновляет
}
}

Query Methods: генерация запросов по имени метода

Spring Data позволяет объявлять методы в интерфейсе репозитория, и автоматически генерировать JPQL-запрос на основе имени:

public interface UserRepository extends JpaRepository<User, Long> {
List<User> findByStatusAndBalanceGreaterThan(Status status, BigDecimal minBalance);
Optional<User> findByEmailIgnoreCase(String email);
Page<User> findByCreatedAtBeforeOrderByCreatedAtDesc(
LocalDateTime cutoff, Pageable pageable
);
}

Правила разбора имени метода:

  • findBy..., readBy..., getBy..., queryBy..., searchBy..., streamBy... — префиксы;
  • And, Or — логические операторы;
  • IgnoreCase, Containing, StartingWith, GreaterThan, Between, In, IsNull — операторы сравнения;
  • OrderBy...Asc/Desc — сортировка;
  • Page<T>, List<T>, Stream<T>, Optional<T> — возвращаемый тип;
  • Pageable, Sort — параметры пагинации и сортировки.

Это мощный инструмент для типовых запросов, но для сложных случаев (JOIN, подзапросы, агрегаты) лучше использовать @Query.

Аннотация @Query

Позволяет явно указать JPQL или нативный SQL:

public interface UserRepository extends JpaRepository<User, Long> {
@Query("SELECT u FROM User u JOIN FETCH u.roles r WHERE r.name = :role")
List<User> findByRole(@Param("role") String roleName);

@Query(value = """
SELECT u.*, COUNT(o.id) as order_count
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
GROUP BY u.id
HAVING COUNT(o.id) > :minOrders
""", nativeQuery = true)
List<Object[]> findActiveUsersWithOrderCount(@Param("minOrders") int minOrders);
}

Проекции и DTO

Часто не нужно загружать всю сущность — достаточно нескольких полей. Spring Data поддерживает интерфейсные и классовые проекции:

public interface UserSummary {
String getName();
String getEmail();
int getOrderCount();
}

public interface UserRepository extends JpaRepository<User, Long> {
@Query("SELECT u.name AS name, u.email AS email, COUNT(o) AS orderCount " +
"FROM User u LEFT JOIN u.orders o GROUP BY u.id")
List<UserSummary> findSummaries();
}

Если объявить UserSummary как интерфейс, Spring создаст динамический прокси. Если как класс с конструктором — будет использоваться конструкторное отображение. Это позволяет избежать избыточной передачи данных и улучшить производительность.

Спецификации и Querydsl

Для сложной динамической фильтрации (например, веб-формы с десятком фильтров) Spring Data интегрируется с:

  • JPA Specifications — на основе Criteria API, позволяет строить предикаты как объекты;
  • Querydsl — генерирует Q-классы (QUser), обеспечивающие 100 % типобезопасный fluent-интерфейс.

Пример Specification:

public class UserSpecs {
public static Specification<User> hasStatus(Status status) {
return (root, query, cb) -> cb.equal(root.get("status"), status);
}

public static Specification<User> balanceGreaterThan(BigDecimal amount) {
return (root, query, cb) -> cb.greaterThan(root.get("balance"), amount);
}
}

// Использование
userRepository.findAll(
where(hasStatus(Status.ACTIVE)).and(balanceGreaterThan(BigDecimal.valueOf(1000)))
);

Архитектурный выбор, сопровождение и эксплуатация

Выбор способа взаимодействия с базой данных — стратегическое решение, влияющее на сроки разработки, стоимость сопровождения, гибкость изменений и масштабируемость системы. Ниже мы рассмотрим критерии выбора подхода, управление целостностью данных, обеспечение повторяемости развёртываний и защиту от типовых ошибок.

Сравнительный анализ подходов

Нет универсального «лучшего» решения. Выбор определяется контекстом проекта. Приведём ориентировочную матрицу принятия решений.

КритерийJDBCSQL-фреймворки (MyBatis, jOOQ)ORM (Hibernate/JPA)Spring Data JPA
Контроль над SQLПолныйПолный (SQL остаётся явным)Частичный (можно использовать @Query, но сложные оптимизации требуют знания генерируемых запросов)Частичный (через @Query или Specifications)
Скорость разработки CRUDНизкая (много шаблонного кода)Средняя (маппинг проще, чем вручную, но без автоматизации)Высокая (автоматическое отслеживание изменений, встроенные операции)Очень высокая (реализация репозиториев не требуется)
Производительность (latency / throughput)Максимальная (минимум накладных расходов)Близка к JDBC, но с небольшими накладными расходами на маппингНиже из-за overhead (dirty checking, прокси, кэширование), но может быть оптимизированаАналогично JPA, но с дополнительным слоем Spring
Сложность отладкиПростая (виден каждый запрос, легко логгировать)Средняя (логгирование настраивается, но есть абстракция)Высокая (нужно понимать, какие запросы генерируются, как работает кэш, lazy loading)Аналогично JPA
Гибкость схемы БДПолная (нет привязки к объектной модели)Полная (SQL пишется вручную)Ограничена: изменения в БД часто требуют синхронного изменения сущностейАналогично JPA
Поддержка legacy-БДОтличная (работает с любой схемой)ОтличнаяСложная (требует адаптации аннотаций, возможно — DTO-слоя)Сложная
Обучение командыТребует знания SQL и JDBCТребует знания SQL и фреймворкаТребует глубокого понимания ORM, жизненного цикла сущностейТребует знания Spring и JPA
Типовые сценарииУтилиты, интеграции, микросервисы с простой моделью, high-load обработчикиПриложения с комплексной бизнес-логикой и тонко настраиваемыми запросами (финансы, аналитика)Корпоративные приложения, ERP-модули, системы с богатой доменной модельюСервисы на Spring Boot с типовыми CRUD-операциями

Рекомендации по выбору:

Используйте JDBC напрямую, если:

  • Вы пишете утилиту (например, миграцию данных, батч-процесс);
  • Требуется максимальная производительность и минимальная задержка;
  • Схема БД чрезвычайно нестандартна (например, динамические столбцы, отсутствие первичных ключей);
  • Вы интегрируетесь с СУБД, для которой нет качественного драйвера ORM (редко, но бывает).

Используйте MyBatis или jOOQ, если:

  • Команда хорошо знает SQL и предпочитает писать запросы вручную;
  • Бизнес-логика требует сложных JOIN, оконных функций, CTE;
  • Вы хотите избежать «магии» ORM, но устали от rs.getString("col");
  • jOOQ особенно уместен, когда схема БД стабильна и можно генерировать метамодель.

Используйте Hibernate/JPA, если:

  • У вас большая, сложная доменная модель с наследованием, полиморфными связями;
  • Важна скорость разработки новых фич при умеренных требованиях к latency;
  • Вы готовы инвестировать в обучение команды и мониторинг SQL-запросов.

Добавьте Spring Data JPA, если:

  • Проект уже использует Spring Boot;
  • Большинство операций — стандартные CRUD или простые фильтрации;
  • Вы цените декларативность и хотите сократить объём шаблонного кода.

Важно: гибридные подходы допустимы и часто предпочтительны. Например:

  • Основная логика на Spring Data JPA;
  • Критически важные отчёты — через jOOQ или нативные запросы в @Query;
  • Интеграция с legacy-таблицей — через JDBC Template (обёртка Spring над JDBC).

Управление транзакциями

Транзакция — это логическая единица работы, обладающая свойствами ACID (Atomicity, Consistency, Isolation, Durability). В Java управление транзакциями может быть:

  • Программным (через Connection.setAutoCommit(false), commit(), rollback());
  • Декларативным (через @Transactional в Spring или UserTransaction в Jakarta EE).

Декларативные транзакции в Spring

Аннотация @Transactional — стандартный способ в Spring Boot. Она работает на уровне прокси: при вызове метода Spring создаёт транзакцию (или присоединяется к существующей), выполняет метод, и при успешном завершении делает commit, при исключении — rollback.

@Service
public class OrderService {

@Transactional
public Order createOrder(Long userId, List<OrderItem> items) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new EntityNotFoundException("User not found"));

Order order = new Order();
order.setUser(user);
order.setItems(items);
order.setStatus(OrderStatus.CREATED);
order = orderRepository.save(order); // flush происходит автоматически

paymentService.charge(user, order.getTotal()); // вызов другого @Transactional-метода
return order;
}
}
Ключевые параметры @Transactional:
  • propagation — как вести себя при вложенном вызове:

    • REQUIRED (по умолчанию) — использовать текущую транзакцию, если есть, иначе создать новую;
    • REQUIRES_NEW — приостановить внешнюю транзакцию и начать новую;
    • NESTED — не поддерживается JPA (только JDBC), создаёт savepoint;
    • NOT_SUPPORTED, NEVER, MANDATORY — для специфических случаев.
  • isolation — уровень изоляции (по умолчанию — ISOLATION_DEFAULT, т.е. уровень СУБД):

    • READ_UNCOMMITTED, READ_COMMITTED, REPEATABLE_READ, SERIALIZABLE;
    • Выбор влияет на производительность и вероятность аномалий (грязное чтение, неповторяющееся чтение, фантомы).
  • timeout — максимальное время выполнения транзакции (в секундах).

  • readOnly — подсказка СУБД, что транзакция только читает данные. Может улучшить производительность (например, в PostgreSQL позволяет использовать «snapshot isolation» без блокировок).

  • rollbackFor, noRollbackFor — уточнение, при каких исключениях делать откат (по умолчанию — при RuntimeException и Error).

Правила работы с транзакциями

  1. Транзакции должны быть как можно короче. Долгие транзакции удерживают блокировки, снижают параллелизм и рискуют превысить таймаут.

  2. Не вызывайте @Transactional-методы из того же класса. Прокси Spring не сработает, и аннотация будет проигнорирована. Решение — вынести в отдельный бин или использовать AopContext.currentProxy() (не рекомендуется).

  3. Избегайте бизнес-логики в методах с @Transactional, если она вызывает внешние сервисы (HTTP, очереди). Если вызов paymentService.charge() в примере выше завершится успешно, но при коммите БД произойдёт сбой, деньги будут списаны, а заказ не сохранится. Для таких случаев нужны распределённые транзакции (XA) или паттерны вроде Saga.

  4. Проверяйте поведение при конфликтах. В высоконагруженных системах возможны deadlocks или optimistic lock failures (OptimisticLockException). Нужно предусмотреть retry-логику (например, через @Retryable из Spring Retry).


Миграции схемы

Автоматическая генерация схемы через hibernate.hbm2ddl.auto неприемлема в production. Она не обеспечивает:

  • Атомарности изменений;
  • Отката (rollback);
  • Проверки совместимости со старыми версиями приложения;
  • Выполнения миграционных скриптов (например, пересчёта данных).

Вместо этого используются инструменты миграций:

Flyway

  • Основан на версионированных SQL-скриптах:
    src/main/resources/db/migration/
    V1__Create_users_table.sql
    V2__Add_balance_column.sql
    V3__Migrate_status_enum.sql
  • Каждый скрипт выполняется один раз, информация о применённых миграциях хранится в служебной таблице flyway_schema_history.
  • Поддерживает repeatable-миграции (R__), Java-миграции, шифрование, dry-run.
  • Интеграция с Spring Boot: достаточно добавить зависимость flyway-core, и миграции запустятся при старте.

Liquibase

  • Описывает изменения в независимом от СУБД формате (XML, YAML, JSON, SQL):
    databaseChangeLog:
    - changeSet:
    id: 1
    author: timur
    changes:
    - createTable:
    tableName: users
    columns:
    - column:
    name: id
    type: bigint
    autoIncrement: true
    constraints:
    primaryKey: true
    - column:
    name: email
    type: varchar(255)
    constraints:
    nullable: false
    unique: true
  • Поддерживает генерацию diff между состояниями, откат изменений (rollback), теги.
  • Более гибкий, но сложнее в освоении.

Рекомендация: для команд, хорошо знающих SQL и предпочитающих прозрачность — Flyway. Для кросс-платформенных проектов или при частой смене СУБД — Liquibase.


Тестирование доступа к данным

Тесты, затрагивающие БД, должны быть:

  • Изолированными (не влиять друг на друга);
  • Повторяемыми (один и тот же результат при каждом запуске);
  • Быстрыми (по возможности).

Стратегии:

  1. Встраиваемая БД (H2)
    Лёгкая in-memory СУБД, совместимая с SQL. Подходит для unit- и интеграционных тестов, где важна логика, а не специфика СУБД.

    @DataJpaTest
    @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
    @TestConfiguration
    static class TestConfig {
    @Bean
    DataSource dataSource() {
    return new EmbeddedDatabaseBuilder()
    .setType(EmbeddedDatabaseType.H2)
    .addScript("schema.sql")
    .addScript("data.sql")
    .build();
    }
    }

    Минусы: H2 не всегда точно имитирует поведение PostgreSQL/Oracle (например, в части блокировок, типов данных, функций).

  2. Testcontainers
    Запускает реальную СУБД в Docker-контейнере. Гарантирует 100 % совместимость.

    @Testcontainers
    class UserRepositoryTest {
    @Container
    static PostgreSQLContainer<?> postgres =
    new PostgreSQLContainer<>("postgres:15");

    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
    registry.add("spring.datasource.url", postgres::getJdbcUrl);
    registry.add("spring.datasource.username", postgres::getUsername);
    registry.add("spring.datasource.password", postgres::getPassword);
    }

    @Test
    void shouldFindUserByEmail() { ... }
    }

    Плюсы: максимальная достоверность.
    Минусы: медленнее, требует Docker.

  3. Мокирование EntityManager
    Не рекомендуется для интеграционных тестов — проверяется только логика сервиса, но не взаимодействие с БД.


Типовые ошибки и антипаттерны

1. N+1 Select Problem

Симптом: при загрузке списка сущностей с ленивыми связями выполняется 1 запрос на список + N запросов на каждую связь.

Пример:

List<User> users = userRepository.findAll(); // 1 запрос
for (User user : users) {
System.out.println(user.getRoles().size()); // N запросов (по одному на пользователя)
}

Решения:

  • Использовать JOIN FETCH в JPQL:
    @Query("SELECT u FROM User u JOIN FETCH u.roles")
    List<User> findAllWithRoles();
  • Настроить @EntityGraph для репозитория;
  • Отказаться от LAZY в пользу EAGER (только если связь всегда нужна и объём данных невелик);
  • Использовать DTO-проекции.

2. LazyInitializationException

Симптом: обращение к ленивой коллекции вне транзакции или после закрытия EntityManager.

Причина: сессия закрыта, прокси не может выполнить запрос.

Решения:

  • Загружать связи в той же транзакции, где запрашивается сущность (JOIN FETCH);
  • Использовать @Transactional на уровне сервиса (не контроллера!);
  • Применять DTO и маппить данные до выхода из слоя данных;
  • Избегать Open Session in View — это маскирует проблему и создаёт долгие транзакции.

3. Частые flush() и clear()

Симптом: низкая производительность при batch-операциях.

Причина: по умолчанию Hibernate синхронизирует состояние с БД перед каждым запросом (flush), чтобы обеспечить согласованность. При вставке 1000 сущностей это приводит к 1000 round-trip.

Решение:

@Transactional
public void bulkInsert(List<User> users) {
for (int i = 0; i < users.size(); i++) {
em.persist(users.get(i));
if (i % 20 == 0) { // batch size
em.flush();
em.clear(); // освобождает память
}
}
}
  • Настройка hibernate.jdbc.batch_size=20, order_inserts=true.

4. Избыточное использование @Transactional(readOnly = true)

Миф: «readOnly ускоряет запросы».
Реальность: в PostgreSQL это лишь отключает autocommit, но не даёт преимуществ. В Oracle может позволить использовать «read-consistent snapshot», но эффект мал. Гораздо важнее:

  • Использовать Pageable вместо findAll();
  • Загружать только нужные поля (проекции);
  • Кэшировать справочники.

Современные тенденции

R2DBC и реактивный доступ к данным

Традиционные JDBC/JPA — блокирующие API: поток приложения ждёт ответа от БД. В реактивных системах (Spring WebFlux) это нарушает принцип non-blocking I/O.

R2DBC (Reactive Relational Database Connectivity) — стандарт для неблокирующего доступа к реляционным БД. Поддерживается PostgreSQL, MySQL, Microsoft SQL Server, H2.

Пример:

@Repository
public class ReactiveUserRepository {
private final DatabaseClient client;

public Flux<User> findAll() {
return client.sql("SELECT * FROM users")
.map(row -> new User(row.get("id", Long.class), row.get("name", String.class)))
.all();
}
}

Spring Data R2DBC предоставляет реактивные репозитории:

public interface ReactiveUserRepository extends ReactiveCrudRepository<User, Long> {
Flux<User> findByStatus(Status status);
}

Когда использовать: высоконагруженные сервисы с большим числом одновременных подключений (микросервисы, API-шлюзы), где важна экономия потоков.

Ограничения: нет поддержки JPA, ORM, ленивой загрузки. Только SQL/H2.

Многомодельные подходы

В современных системах часто требуется комбинировать реляционные и нереляционные хранилища:

  • PostgreSQL с JSONB — для гибких сущностей;
  • Hibernate ORM + Hibernate Search — для полнотекстового поиска через Elasticsearch;
  • Spring Data JDBC + Spring Data MongoDB — когда часть данных хорошо ложится в документную модель.

Ключевой принцип: выбирать хранилище под задачу, а не под технологический стек.


Примеры: управление книгами (Book)

Сущность Book:

  • idBIGINT, автоинкремент, PK
  • titleVARCHAR(255), NOT NULL
  • authorVARCHAR(255), NOT NULL
  • isbnVARCHAR(17), UNIQUE
  • published_yearINT
  • availableBOOLEAN, по умолчанию true

СУБД: PostgreSQL (но примеры легко адаптируются под другие через смену драйвера и DDL).


1. Чистый JDBC + HikariCP

Зависимости (Maven):

<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>42.7.3</version>
</dependency>
<dependency>
<groupId>com.zaxxer</groupId>
<artifactId>HikariCP</artifactId>
<version>5.1.0</version>
</dependency>

DDL (schema.sql):

CREATE TABLE books (
id BIGSERIAL PRIMARY KEY,
title VARCHAR(255) NOT NULL,
author VARCHAR(255) NOT NULL,
isbn VARCHAR(17) UNIQUE NOT NULL,
published_year INT,
available BOOLEAN DEFAULT true
);

Класс Book (DTO/сущность):

public class Book {
private Long id;
private String title;
private String author;
private String isbn;
private Integer publishedYear;
private Boolean available;

// конструкторы, геттеры, сеттеры
public Book() {}

public Book(String title, String author, String isbn, Integer publishedYear) {
this.title = title;
this.author = author;
this.isbn = isbn;
this.publishedYear = publishedYear;
this.available = true;
}

// ... геттеры и сеттеры
}

Класс JdbcBookRepository:

import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;

import javax.sql.DataSource;
import java.sql.*;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;

public class JdbcBookRepository {

private final DataSource dataSource;

public JdbcBookRepository() {
HikariConfig config = new HikariConfig();
config.setJdbcUrl("jdbc:postgresql://localhost:5432/library");
config.setUsername("user");
config.setPassword("password");
config.setMaximumPoolSize(10);
// Автоматическое восстановление разорванных соединений
config.setConnectionTestQuery("SELECT 1");
this.dataSource = new HikariDataSource(config);
}

// Создание книги
public Book save(Book book) {
String sql = """
INSERT INTO books (title, author, isbn, published_year, available)
VALUES (?, ?, ?, ?, ?)
RETURNING id
""";

try (Connection conn = dataSource.getConnection();
PreparedStatement stmt = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {

stmt.setString(1, book.getTitle());
stmt.setString(2, book.getAuthor());
stmt.setString(3, book.getIsbn());
stmt.setInt(4, book.getPublishedYear());
stmt.setBoolean(5, book.getAvailable() != null ? book.getAvailable() : true);

int affectedRows = stmt.executeUpdate();
if (affectedRows == 0) {
throw new SQLException("Создание книги не привело к вставке данных.");
}

try (ResultSet generatedKeys = stmt.getGeneratedKeys()) {
if (generatedKeys.next()) {
book.setId(generatedKeys.getLong(1));
} else {
throw new SQLException("Не удалось получить сгенерированный ID.");
}
}

return book;

} catch (SQLException e) {
throw new RuntimeException("Ошибка при сохранении книги", e);
}
}

// Чтение по ID
public Optional<Book> findById(Long id) {
String sql = "SELECT id, title, author, isbn, published_year, available FROM books WHERE id = ?";

try (Connection conn = dataSource.getConnection();
PreparedStatement stmt = conn.prepareStatement(sql)) {

stmt.setLong(1, id);
try (ResultSet rs = stmt.executeQuery()) {
if (rs.next()) {
return Optional.of(mapRow(rs));
}
return Optional.empty();
}

} catch (SQLException e) {
throw new RuntimeException("Ошибка при поиске книги по ID", e);
}
}

// Чтение всех доступных книг
public List<Book> findAllAvailable() {
String sql = "SELECT id, title, author, isbn, published_year, available FROM books WHERE available = true";

try (Connection conn = dataSource.getConnection();
PreparedStatement stmt = conn.prepareStatement(sql);
ResultSet rs = stmt.executeQuery()) {

List<Book> books = new ArrayList<>();
while (rs.next()) {
books.add(mapRow(rs));
}
return books;

} catch (SQLException e) {
throw new RuntimeException("Ошибка при получении списка книг", e);
}
}

// Обновление
public boolean update(Book book) {
String sql = """
UPDATE books
SET title = ?, author = ?, isbn = ?, published_year = ?, available = ?
WHERE id = ?
""";

try (Connection conn = dataSource.getConnection();
PreparedStatement stmt = conn.prepareStatement(sql)) {

stmt.setString(1, book.getTitle());
stmt.setString(2, book.getAuthor());
stmt.setString(3, book.getIsbn());
stmt.setInt(4, book.getPublishedYear());
stmt.setBoolean(5, book.getAvailable());
stmt.setLong(6, book.getId());

return stmt.executeUpdate() > 0;

} catch (SQLException e) {
throw new RuntimeException("Ошибка при обновлении книги", e);
}
}

// Удаление
public boolean deleteById(Long id) {
String sql = "DELETE FROM books WHERE id = ?";
try (Connection conn = dataSource.getConnection();
PreparedStatement stmt = conn.prepareStatement(sql)) {
stmt.setLong(1, id);
return stmt.executeUpdate() > 0;
} catch (SQLException e) {
throw new RuntimeException("Ошибка при удалении книги", e);
}
}

// Вспомогательный метод: ResultSet → Book
private Book mapRow(ResultSet rs) throws SQLException {
Book book = new Book();
book.setId(rs.getLong("id"));
book.setTitle(rs.getString("title"));
book.setAuthor(rs.getString("author"));
book.setIsbn(rs.getString("isbn"));
book.setPublishedYear(rs.getInt("published_year"));
book.setAvailable(rs.getBoolean("available"));
return book;
}
}

Комментарии:
— Использован HikariCP для пулинга — обязательное условие для production.
— Все ресурсы закрываются через try-with-resources.
— Обработка SQLException с сохранением стека.
RETURNING id — стандартный способ получения сгенерированного ключа в PostgreSQL.


2. JPA (Hibernate) — сущность и EntityManager

Зависимости (Maven):

<dependency>
<groupId>org.hibernate</groupId>
<artifactId>hibernate-core</artifactId>
<version>6.4.4.Final</version>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>42.7.3</version>
</dependency>

Класс Book (сущность JPA):

import jakarta.persistence.*;

@Entity
@Table(name = "books")
public class Book {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column(nullable = false)
private String title;

@Column(nullable = false)
private String author;

@Column(unique = true, nullable = false)
private String isbn;

@Column(name = "published_year")
private Integer publishedYear;

private Boolean available = true;

// Обязательен конструктор без параметров для JPA
public Book() {}

public Book(String title, String author, String isbn, Integer publishedYear) {
this.title = title;
this.author = author;
this.isbn = isbn;
this.publishedYear = publishedYear;
}

// Геттеры и сеттеры (или final-поля + конструктор — но тогда нужен @Access)
// ...

// equals/hashCode по id (или по business key, например, isbn)
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Book book)) return false;
return id != null && id.equals(book.id);
}

@Override
public int hashCode() {
return getClass().hashCode();
}
}

Настройка EntityManagerFactory (вручную, без Spring):

import org.hibernate.cfg.Configuration;

import jakarta.persistence.EntityManagerFactory;

public class JpaConfig {
public static EntityManagerFactory createEntityManagerFactory() {
return new Configuration()
.addAnnotatedClass(Book.class)
.setProperty("hibernate.dialect", "org.hibernate.dialect.PostgreSQLDialect")
.setProperty("hibernate.connection.driver_class", "org.postgresql.Driver")
.setProperty("hibernate.connection.url", "jdbc:postgresql://localhost:5432/library")
.setProperty("hibernate.connection.username", "user")
.setProperty("hibernate.connection.password", "password")
.setProperty("hibernate.hbm2ddl.auto", "validate") // только валидация!
.setProperty("hibernate.show_sql", "true")
.setProperty("hibernate.format_sql", "true")
.buildSessionFactory();
}
}

Класс JpaBookRepository:

import jakarta.persistence.*;

import java.util.List;
import java.util.Optional;

public class JpaBookRepository {

private final EntityManagerFactory emf;

public JpaBookRepository(EntityManagerFactory emf) {
this.emf = emf;
}

public Book save(Book book) {
EntityManager em = emf.createEntityManager();
EntityTransaction tx = null;
try {
tx = em.getTransaction();
tx.begin();
Book saved = em.merge(book); // persist для новых, merge — для detached
tx.commit();
return saved;
} catch (Exception e) {
if (tx != null && tx.isActive()) {
tx.rollback();
}
throw new RuntimeException("Ошибка при сохранении книги", e);
} finally {
em.close();
}
}

public Optional<Book> findById(Long id) {
EntityManager em = emf.createEntityManager();
try {
return Optional.ofNullable(em.find(Book.class, id));
} finally {
em.close();
}
}

@SuppressWarnings("unchecked")
public List<Book> findAllAvailable() {
EntityManager em = emf.createEntityManager();
try {
return em.createQuery(
"SELECT b FROM Book b WHERE b.available = true", Book.class)
.getResultList();
} finally {
em.close();
}
}

public List<Book> findByAuthorAndYear(String author, int year) {
EntityManager em = emf.createEntityManager();
try {
TypedQuery<Book> query = em.createQuery(
"SELECT b FROM Book b WHERE b.author = :author AND b.publishedYear = :year", Book.class);
query.setParameter("author", author);
query.setParameter("year", year);
return query.getResultList();
} finally {
em.close();
}
}

public void delete(Book book) {
EntityManager em = emf.createEntityManager();
EntityTransaction tx = null;
try {
tx = em.getTransaction();
tx.begin();
em.remove(em.contains(book) ? book : em.merge(book));
tx.commit();
} catch (Exception e) {
if (tx != null && tx.isActive()) {
tx.rollback();
}
throw new RuntimeException("Ошибка при удалении книги", e);
} finally {
em.close();
}
}
}

Комментарии:
— Явное управление транзакциями и EntityManager — необходимо без Spring.
merge() вместо persist() позволяет работать с detached-объектами.
em.contains() проверяет, управляется ли объект текущей сессией.
— Для production рекомендуется использовать @Transactional (см. следующий раздел).


3. Spring Data JPA

Зависимости (Spring Boot):

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
</dependency>

application.properties:

spring.datasource.url=jdbc:postgresql://localhost:5432/library
spring.datasource.username=user
spring.datasource.password=password
spring.jpa.hibernate.ddl-auto=validate
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true

Сущность Book — та же, что в разделе 2 (можно оставить без изменений).

Репозиторий:

import org.springframework.data.jpa.repository.*;
import org.springframework.data.repository.query.Param;

import java.util.List;
import java.util.Optional;

public interface BookRepository extends JpaRepository<Book, Long> {

// Генерация по имени метода
List<Book> findByAvailableTrue();
List<Book> findByAuthorAndPublishedYear(String author, Integer year);
Optional<Book> findByIsbn(String isbn);

// Проекция: интерфейс
interface BookTitleAuthor {
String getTitle();
String getAuthor();
}

// Возврат проекции
List<BookTitleAuthor> findTop5ByAvailableTrueOrderByPublishedYearDesc();

// Явный JPQL
@Query("SELECT b FROM Book b WHERE LOWER(b.title) LIKE LOWER(CONCAT('%', :query, '%'))")
List<Book> searchByTitleFragment(@Param("query") String query);

// Нативный SQL (осторожно!)
@Query(value = """
SELECT b.*, COUNT(r.id) as reservation_count
FROM books b
LEFT JOIN reservations r ON b.id = r.book_id AND r.status = 'ACTIVE'
GROUP BY b.id
HAVING COUNT(r.id) < :maxReservations
""", nativeQuery = true)
List<Object[]> findBooksUnderReservationLimit(@Param("maxReservations") int maxReservations);
}

Сервис:

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;
import java.util.Optional;

@Service
@Transactional
public class BookService {

private final BookRepository bookRepository;

public BookService(BookRepository bookRepository) {
this.bookRepository = bookRepository;
}

public Book createBook(String title, String author, String isbn, Integer year) {
Book book = new Book(title, author, isbn, year);
return bookRepository.save(book); // INSERT или UPDATE
}

@Transactional(readOnly = true)
public Optional<Book> getBook(Long id) {
return bookRepository.findById(id);
}

@Transactional(readOnly = true)
public List<Book> getAvailableBooks() {
return bookRepository.findByAvailableTrue();
}

@Transactional(readOnly = true)
public List<BookRepository.BookTitleAuthor> getRecentBookPreviews() {
return bookRepository.findTop5ByAvailableTrueOrderByPublishedYearDesc();
}

public void markAsUnavailable(Long id) {
bookRepository.findById(id)
.ifPresent(book -> {
book.setAvailable(false);
// save() не требуется — изменения отслеживаются автоматически
});
}
}

Комментарии:
@Transactional на сервисе — правильный уровень.
readOnly = true для операций чтения — подсказка Hibernate.
— Проекции позволяют избежать загрузки всей сущности.
save() возвращает managed-сущность — можно использовать для дальнейших операций.


4. Spring Data R2DBC (реактивный)

Зависимости (Spring Boot WebFlux):

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-r2dbc</artifactId>
</dependency>
<dependency>
<groupId>io.r2dbc</groupId>
<artifactId>r2dbc-postgresql</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
</dependency>

application.properties:

spring.r2dbc.url=r2dbc:postgresql://localhost:5432/library
spring.r2dbc.username=user
spring.r2dbc.password=password

Рекорд Book (иммутабельный, для реактивного стиля):

import org.springframework.data.annotation.Id;
import org.springframework.data.relational.core.mapping.Table;

@Table("books")
public record Book(
@Id Long id,
String title,
String author,
String isbn,
Integer publishedYear,
Boolean available
) {
public Book(String title, String author, String isbn, Integer publishedYear) {
this(null, title, author, isbn, publishedYear, true);
}
}

Репозиторий:

import org.springframework.data.r2dbc.repository.Query;
import org.springframework.data.r2dbc.repository.R2dbcRepository;
import org.springframework.data.repository.reactive.ReactiveSortingRepository;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

public interface ReactiveBookRepository extends R2dbcRepository<Book, Long> {

Flux<Book> findByAvailable(boolean available);

Mono<Book> findByIsbn(String isbn);

// Проекция через record
record BookPreview(String title, String author) {}

@Query("SELECT title, author FROM books WHERE available = true ORDER BY published_year DESC LIMIT 5")
Flux<BookPreview> findRecentPreviews();
}

Сервис:

import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;

@Service
public class ReactiveBookService {

private final ReactiveBookRepository bookRepository;

public ReactiveBookService(ReactiveBookRepository bookRepository) {
this.bookRepository = bookRepository;
}

public Mono<Book> createBook(String title, String author, String isbn, Integer year) {
Book book = new Book(title, author, isbn, year);
return bookRepository.save(book);
}

public Mono<Book> getBook(Long id) {
return bookRepository.findById(id);
}

public Flux<Book> getAvailableBooks() {
return bookRepository.findByAvailable(true);
}

public Flux<ReactiveBookRepository.BookPreview> getRecentPreviews() {
return bookRepository.findRecentPreviews();
}

public Mono<Void> markAsUnavailable(Long id) {
return bookRepository.findById(id)
.flatMap(book -> {
Book updated = new Book(
book.id(),
book.title(),
book.author(),
book.isbn(),
book.publishedYear(),
false
);
return bookRepository.save(updated).then();
});
}
}

Комментарии:
record идеально подходит для DTO в реактивном стеке.
— Все методы возвращают Mono/Flux — неблокирующие типы.
— Изменение объекта требует создания нового экземпляра (иммутабельность).
@Query в R2DBC поддерживает только нативный SQL (JPQL недоступен).


Сравнение в одном взгляде

ОперацияJDBCJPASpring Data JPAR2DBC
СозданиеINSERT ... RETURNING id + getGeneratedKeys()em.merge()repository.save()repository.save()
Чтение по IDSELECT ... WHERE id = ?em.find()repository.findById()repository.findById()
Фильтрацияручной SQL + параметрыJPQL / Criteriaquery methods / @Query@Query (SQL) / методы
ИзменениеUPDATE ...dirty checking (авто)dirty checking (авто)создание нового объекта
Транзакцииручное управлениеEntityTransaction@TransactionalTransactionalOperator
Ленивые связинетLAZY + проксикак в JPAне поддерживается